Effective-Objective-C读书笔记(2)

对象、消息、runtime

Objective-C 语言中,“对象”是“基本的构造单元”,在对象之间传递数据并执行任务,这个过程叫做消息传递。为其提供相关支持的代码叫做Objective-C runtime,它提供了一些使得对象间能够传递消息的重要函数。

第6条:理解属性这一概念

属性是 Objective-C 中用于封装对象的数据。在 Objective-C 语言中,很少像 C++、Java 那样在接口内部声明实例变量,例如:

1
2
3
4
5
6
7
@interface EOCPerson : NSObject
@public
NSString *_firstName;
NSString *_lastName;
@private
NSString *_someInternalData;
@end

不像 C++、Java 那样,在这里可以定义实例变量的作用域。在 Objective-C 中,这种写法的问题是:对象布局在编译期间就已经固定了。只要访问_firstName,编译器就把其替换为偏移量(这个偏移量是硬编码,表示该变量距离存放对象的内存区域的起始地址有多远)。如果在 _firstName 前面又多添加一个实例变量 NSDate *_dateOfBirth;,这样 _firstName 偏移量就会改变,指向 _dateOfBirth,偏移量硬编码于其中就会读到错误的值。此时内存的布局如图所示:

如果代码使用了编译期计算出来的偏移量,那么在修改类定义之后必须重新编译,否则就会出错。然而这种将实例变量声明在@interface接口中暴露出类的接口,更好的方式是通过@property 语法来实现。可以像以下代码来声明属性:

1
2
3
4
@interface EOCPerson : NSObject
@property NSString *firstName;
@property NSString *lastName;
@end

使用@property属性,编译器会在编译期自动做以下几件事情:

  • 自动合成这些属性的 getter、setter 方法,开发者并不可见这些合成方法的源代码。
  • 向类中添加适当类型的实例变量,并在属性名前加_,作为实例变量的名字。

如果你不喜欢以_开头的实例变量名,可以通过@synthesize语法来指定实例变量的名字:

1
2
3
4
@interface EOCPerson
@synthesize firstName = _myFirstName;
@synthesize lastName = _myLastName;
@end

上面会将实例变量命名为_myFirstName_myLastName

若不想令编译器自动合成存取方法,也可以自己实现。通过使用@dynamic关键字,告诉编译器:不要自动创建属性所用的实例变量,也不要为其创建存取方法。而且,在编译期间访问属性代码时,即使编译器没有定义存取方法,也不会报错,它相信这些方法能在运行期找到。比如 Core Data 中 NSManagedObject的子类

属性特质
  • atomic:修饰属性会给属性的设置方法加同步锁,iOS 同步锁开销大,会影响性能。若自定义方法,应该遵守与属性特质相符的原子性。然而这并不能保证其线程安全,需要更深层的锁机制才行。
  • nonatomic:不使用同步锁,
  • readwrite:编译器生成对应的gettersetter方法。
  • readonly:编译器只生成getter方法,可以在class continuation中将其定义为 readwrite 属性,保持属性在外部是 readonly 的。
  • assign:只针对基本“纯量类型”(scalar type):例如 CGFloat、NSInteger
  • strong:属性为拥有关系,设置新值时,会 retain 新值,release 旧值,然后再将新值设置设置上去。
  • weak:属性为非拥有关系,既不 retain 新值,也不 release 旧值,对象销毁的时候,属性值会清空(置 nil)
  • unsage_unretained:与 weak 相似,但是对象销毁的时候,修饰的属性并不自动清空,所以是不安全的。
  • copy:与 strong 类似,设置方法是将其 copy。用此方法保持属性的封装性。
  • getter=name:指定 getter 的方法名,例如 UISwitch 中,@property (nonatomic, getter=isOn) BOOL on;
  • setter=name:指定 setter 方法名,不常见。

第7条:在对象内部尽量直接访问实例变量

  • 在对象内部读取数据时,应该直接通过实例变量来读,而写入数据,则应通过属性来写。
  • 在初始化方法及 dealloc 方法中,总是应该直接通过实例变量来读写数据。
  • 在使用惰性初始化时,通过属性来获取数据

第8条:理解”对象同等性”

1
2
3
4
5
NSString *foo = @"Badge 123";
NSString *bar = [NSString stringWithFormat:@"Badge %i", 123];
BOOL equalA = (foo == bar); // NO
BOOL equalB = [foo isEqual:bar]; // YES
BOOL equalC = [foo isEqualToString:bar]; // YES

在判断对象是否相等,需要覆写 isEqualisEqualToString 方法:

  • 检测对象同等性,需要提供isEqualhash方法。
  • 相同的对象必须具有相同的 hash 码,而两个相同 hash 码的对象不一定相同。

第9条:以“类簇模式”隐藏实现细节

  • 使用类簇可以把公共的接口隐藏在父类里面,比如 buttonWithType:方法,根据类型返回不同的 button 实例,其类型是隐藏在类簇的公共接口后面的某个内部类型。也可以称为门面模式
  • 系统的常用框架经常使用类簇,比如 典型的有 UIButton,collection 类等。

第10条:在既有类中使用关联对象存放自定义数据

Objective-C 可以通过关联对象给某对象关联许多其他对象,这些对象通过key来区分,还可以指明存储策略,用来维护相应的内存管理语句。其由objc_AssociationPolicy的枚举所定义。可以把关联对象理解为一个 NSDictionary,拥有对应的存取值方法,与之不同的是,存取关联对象的值是个不透明的指针

  • 可以通过关联对象将两个对象连接起来。
  • 定义关联对象可以指定其内存管理语句,用来模仿定义属性时所采用的拥有关系非拥有关系
  • 关联对象之间的关系并没有正式定义,其内存管理语句是在关联的时候才定义的,使用时要小心。

第11条:理解 objc_msgSend 的作用

在 C 语言中,大部分程序是静态绑定的。也就是说程序在编译期间就能得到运行时所调用的函数。然而当 C 程序中存在函数指针的时候,编译期就无法得知该函数的定义,直到运行时才能决定。这就是动态绑定。在 Objective-C 中就使用动态绑定机制来决定调用的方法。在底层,所有的方法都是普通的 C 函数实现,调用哪个函数都是由运行时来改变。这种特性使得 Objective-C 称为动态的语言。发送消息可以这样写:

1
2
3
4
// someObject:消息的接收者
// messageName:称为选择子(selector)
// selector 和参数结合起来称为`消息`
id returnValue = [someObject messageName:parameter];

编译器看到消息后,将其转换为一条标准的 C 语言函数调用,其原型如下:

1
2
3
4
5
// objc_msgSend:是可变参数的函数
// self:消息接收者
// SEL:selector(也就是选择子)
// 后续参数就是消息中的参数,其顺序不变
void objc_msgSend(id self, SEL cmd, ...)

上面的函数经过编译器转换后变成:

1
id returnValue = objc_msgSend(someObject, @selector(messageName:), paramter);

objc_msgSend会根据接收者与选择自的类型调用适当的方法。该方法会在接受者所属的类中搜索其方法列表(list of method),如果能找到与 selector 对应的方法,就跳至其实现代码。如果找不到,就沿着继承体系继续向上寻找,等找到匹配的方法后再跳转。如果还是找不到,就执行消息转发操作。

执行消息查找需要很多步骤,所幸的是 objc_msgSend 会将匹配的结果缓存到快速映射表(fast map)当中,下次查找执行就会很快。过程看起来很耗时,但是实际上,消息派发(message dispatch)并不是应用程序瓶颈所在。

上面只是将消息调用过程,当然还有一些特殊情况:

  • objc_msgSend_stret:如果带发送的消息返回结构体,可交由此函数处理。(这并不是绝对的,只有 CPU 寄存器能容纳下消息返回类型是,该函数才能处理此消息。若返回值无法容纳与 CPU 寄存器中,比如返回的结构体太大,就交个另一个函数进行派发。此时,函数通过分配在栈上的某个变量来处理消息返回的结构体。)
  • objc_msgSend_fpret:消息返回浮点数,则交个此函数处理,这是针对某些架构的 CPU 中(比如 x86)做出特殊处理,这种情况下使用 objc_msgSend 并不合适。
  • objc_msgSend_Super:给父类发送消息,如 [super message:parameter],另外有两个与:objc_msgSend_stretobjc_msgSend_fpret等效的函数,用于处理法给 super 的相应消息。

上面提到,objc_msgSend 一旦找到相应函数的实现,就会进行跳转。能这样做的原因是,Objective-C 对象每个方法都可以认为是简单的 C 函数,其原型如下:

1
<return_type> Class_selector(id self, SEL _cmd, ...)

其工作原理是:每个类中都要有一张表格,其中的指针指向这种函数,selector 作为查找表格所用的 key。这里要注意:原型与 objc_msgSend 函数很像,这是为了利用尾递归优化技术,这项优化非常关键,如果不这么做,在查找函数的过程当中就会频繁的调用堆栈,插入新的栈帧,造成栈溢出。而尾递归优化可以避免这一现象。

第12条:理解消息转发机制

在编译期间向类发送器无法解读的消息,并不会报错,因为在运行时可以继续向类中添加方法,所以编译器此时无法确定该类中到底会不会有某个方法实现。如果某个对象收到无法解读的消息,runtime 就会触发消息转发机制。我们已经遇到过消息转发流程所处理的消息了,比如这样:

1
2
-[__NSCFNumber lowercaseString]: unrecongnized selector sent to instance 0x87
*** Terminating app due to uncaught exception 'NSInvalidArgumentException', reason: '-[__NSCFNumber lowercaseString]: unrecongnized selector sent to instance 0x87'

上面这段异常就是 NSObject 的 doesNotRecongnizeSelector: 方法所抛出的,表明 NSNumber 没有 lowercaseString 方法。__NSCFNumber 是为了实现桥接而使用的内部类。我们在编写程序的过程中,可以在消息转发的过程中设置钩子,用以执行预定的逻辑,不应该使程序崩溃。

消息转发分为两个阶段:

  • 1.动态方法解析(dynamic method resolution):先问消息接收者,所属的类,看其能否动态添加方法来处理当前未知的消息。
  • 2.完整消息转发(full forwarding mechanism):此时第一阶段已经完成,无法执行动态方法解析。runtime 使用其他手段来处理消息。

上面的完整消息转发又包括两步:

  • a).首先,请消息接收者查看有没有其他对象能处理这条消息,若有 runtime 则执行消息转发给该对象,一切正常。反之则执行 b)
  • b).没有备援的消息接收者,则启动完整的消息转发机制,runtime 会将消息封装到 NSInvocation 对象中,再给消息接收者最后一次机会,令其解决当前未处理的消息。

动态方法解析:

对象收到无法解读的消息,会调用其所属类的类方法:+ (BOOL)resolveInstanceMethod:(SEL)selector。该方法表示该类能否新增实例方法来处理 SEL。继续执行转发机制之前,本类有机会新增一个处理此 selector 的方法。如果未实现的方法不是实例方法而是类方法,runtime 就会调用另外一个方法 resolveClassMethod:

备援接收者:

当前接收者有第二次机会处理未知的选择子,runtime 会询问能否将消息转给其他接收者来处理。该步骤通过以下处理方法:

1
2
// 参数代表未知的 selector,若找到备援对象,将其返回,没有找到,返回 nil
- (id)forwardingTargetForSelector:(SEL)selector

注意:我们无法操作经由这一步所转发的消息。若想在发送给备援接收者之前先修改消息内容,就必须通过消息转发机制来做。

完整的消息转发:

消息转发来到这一步,唯一能做的就是启动完整的消息转发机制。首先创建 NSInvocation 对象,把尚未处理的消息封装其中(包括 selector、target、参数),消息派发系统将消息派发给目标对象。此步骤调用下列方法来转发消息:

1
- (void)forwardInvocation:(NSInvocation *)invocation

此方法会按照继承体系来寻找,继承体系中每个类都有机会处理此调用请求,直到 NSObject。如果最后调用了 NSObject 的方法,该方法还会继而调用 doesNotRecongnizeSelector: 以抛出异常,表明此 selector 未被处理。

整个流程图如下:

第13条:method swizzling

类的方法列表会把 selector 的名称映射到相关方法的实现上,这样动态消息派发系统就能根据此找到应该调用的方法。该方法用 IMP 指针来表示,其原型:

1
id (*IMP)(id, SEL, ...)

比如 NSString 类的部分方法表:

也可以把方法表中的 IMP 进行交换:

有以下方法的 API:

1
2
3
4
5
// 交换两个方法的实现
void method_exchangeImplementations(Method m1, Method m2)

// 获取待交换两个参数的方法
Method class_getInstanceMethod(Class aClass, SEL aSelector)

举个例子,当调用 lowercaseString 的时候,输出日志:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@interface NSString (EOCMyAdditions)
- (NSString *)eoc_myLowercaseString;
@end


@implementation NSString (EOCMyAdditions)
- (NSString *)eoc_myLowercaseString {
// 这段代码看似会陷入递归调用的死循环,不要忘记这个方法已经与 lowercaseString 互换了,其实是调用 lowercaseString: 方法的实现
NSString *lowercase = [self eoc_myLowercaseString];
NSLog(@"%@ => %@", self, lowercase);
return lowercase;
}
@end

// 调用方式
Method originalMethod = class_getInstanceMethod([NSString class], @selector(lowercaseString));
Method swappedMethod = class_getInstanceMethod([NSString class], @selector(eoc_myLowercaseString));
method_exchangeImplementations(originalMethod, swappedMethod);

NSString *string = @"This iS a stRiNg";
NSString *lowercaseString = [string lowercaseString]; // This iS a stRiNg => this is a string

交换后其方法表如下:

第13条:理解“类对象”

Objective-C 中有个特殊的类型叫做 id,它只带任意的 Objective-C 对象类型。一般情况下,应该指名下次接收者的具体类型,这样向其发送无法解读的消息,那么编译器就会产生警告。而 id 类型则不然,编译器假定它能相应所有消息。比如:

1
2
3
// 指定具体的类型的好处是:该类实例上调用其所没有的方法时,编译器会得知此情况并发出警告。
NSString *pointerVariable = @"Some string";
id genericTypedString = @"Some string";

id类型被定义在运行期程序库的头文件里:

1
2
3
4
5
6
7
8
9
10
/// An opaque type that represents an Objective-C class.
typedef struct objc_class *Class;

/// Represents an instance of a class.
struct objc_object {
Class isa OBJC_ISA_AVAILABILITY;
};

/// A pointer to an instance of a class.
typedef struct objc_object *id;

由此可见,每个对象的结构体的首个成员是 Class 类的变量,该变量定义了结构体所属的类,称为 is a 指针,如上面的例子中对象“是一个” (is a)NSString 指针,所以其 is a 指针就指向 NSString。Class 对象在 runtime.h 中可以找到:

1
2
3
4
5
6
7
8
9
10
11
12
struct objc_class {
Class isa;
Class super_class;
const char *name;
long version;
long info;
long instance_size;
struct objc_ivar_list *ivars;
struct objc_method_list **methodLists;
struct objc_cache *cache;
struct objc_protocol_list *protocols;
};

此结构体存放类的元数据(metadata),其结构分析如下:

  • isa:指向 Class 所属的类型,也就是 metaclass,用来表示类对象本身具备的元数据。“类方法”就定义在这里,因为这些方法可以理解成类对象的实例方法。每个类仅有一个类对象,而每个“类对象”仅有一个与之相关的“元类”。没错,类可以理解为 metaclass 的实例。
  • super_class:指向 Class 的父类。
  • name:类名
  • version:版本号
  • info:存放额外的信息
  • instance_size:Class 实例的大小。
  • methodLists:上面提到的方法表。
  • cache:方法缓存
  • protocols:协议表

假如,有个名为 SomeClass 的子类从 NSObject 继承而来,继承结构如下图: